Zustand and Axios
以下文件描述的前因後果請參閱 Introduction
安裝
pnpm add zustand immer axios
建立所需的 Slice
相較於 Redux Toolkit, 不需要使用 context provider 將 store 傳遞下去, 便可以在全域讀取 Zustand 的 props
雖然 Redux Toolkit 可以透過 RTK 快速建立 store
跟 action
, 但仍要寫不少的前置 code, 而在 Zustand 中只要使用 create()
就能快速建立 store
跟 action
.
Zustand 是基於 React 的 Hook API
所設計, 使用上更加簡易自然, 要讀取 props 或 dispatch action 都可以直接使用 create() 建立的 hook 就可以將 props 跟 action 從 store 中讀取出來
import { isEmpty } from 'lodash-es';
import { NavigateFunction } from 'react-router-dom';
import { StateCreator } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { useMainStore } from '@/store';
import { instance } from '@/utils/request.utility.ts';
import { appendStr } from '@/utils/string.utility.ts';
import { saveAccessTokenCookie } from '@/utils/token.utility.ts';
export interface AuthSlice {
auth: {
stVerifyQuery: (params: { st: string; navigate: NavigateFunction; search: string }) => Promise<void>;
};
}
export const authSlice: StateCreator<AuthSlice, [], [['zustand/devtools', never], ['zustand/immer', never]]> = devtools(
immer(() => ({
auth: {
stVerifyQuery: async ({ st, search, navigate }) => {
try {
const { data } = await instance({
url: `/auth/access-token/${st}`,
method: 'GET'
});
saveAccessTokenCookie(data);
const params = new URLSearchParams(search);
const queriedStr = Object.fromEntries([...params]);
delete queriedStr.st;
const redirectStr = isEmpty(queriedStr) ? '' : appendStr('', queriedStr);
navigate(`${window.location.pathname}${redirectStr}`, { replace: true });
} catch (data: any) {
useMainStore.getState().error.onErrorDataChange('stVerifyError', data);
}
navigate(window.location.pathname, { replace: true });
useMainStore.getState().fetchStatus.onFetchStatusChange('isStVerifying', false);
}
}
}))
);
import { StateCreator } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
export interface ErrorType {
key?: string;
message: string;
statusCode: number;
}
export interface ErrorSlice {
error: {
errorData: { [key: string]: ErrorType };
onErrorDataChange: (field: string, value: ErrorType) => void;
};
}
export const errorSlice: StateCreator<ErrorSlice, [], [['zustand/devtools', never], ['zustand/immer', never]]> = devtools(
immer((set: (fn: (state: ErrorSlice) => void) => void) => ({
error: {
errorData: {},
onErrorDataChange: (field: string, value: ErrorType) => {
set(state => {
state.error.errorData[field] = value;
});
}
}
}))
);
import { StateCreator } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
export interface FetchStatusSlice {
fetchStatus: {
fetchStatus: { [key: string]: boolean };
onFetchStatusChange: (field: string, value: boolean) => void;
};
}
export const fetchStatusSlice: StateCreator<FetchStatusSlice, [], [['zustand/devtools', never], ['zustand/immer', never]]> = devtools(
immer((set: (fn: (state: FetchStatusSlice) => void) => void) => ({
fetchStatus: {
fetchStatus: {},
onFetchStatusChange: (field, value) => {
set(state => {
state.fetchStatus.fetchStatus[field] = value;
});
}
}
}))
);
import { StateCreator } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { useMainStore } from '@/store';
import { instance } from '@/utils/request.utility.ts';
export interface User {
email: string;
}
export interface UserSlice {
user: {
currentUser: User | undefined;
fetchProfile: () => void;
onCurrentUserChange: (value: User | undefined) => void;
};
}
export const currentUserSlice: StateCreator<UserSlice, [], [['zustand/devtools', never], ['zustand/immer', never]]> = devtools(
immer(set => ({
user: {
currentUser: undefined,
fetchProfile: async () => {
try {
const { data } = await instance({
url: `/auth/profile`,
method: 'GET'
});
set(state => {
state.user.currentUser = data;
});
} catch (error) {
console.error(error);
}
useMainStore.getState().fetchStatus.onFetchStatusChange('isProfileFetching', false);
},
onCurrentUserChange: value => {
set(state => {
state.user.currentUser = value;
});
}
}
}))
);
- 我們可以同 Redux Toolkit 一樣先切成各個 slice, 然後再透過 create() 來建立 store
- 在 store 中可以使用
set()
更新 state, 使用get()
取得 state - 另外當我們需要更新 nested object 的時候, 可以透過
immer
middleware 來幫助我們更方便的更新 state - Zustand 可以結合 redux devtool (
devtools
) 來幫助我們 debug
import { StoreApi, UseBoundStore } from 'zustand';
import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import { AuthSlice, authSlice } from '@/store/slice/auth.slice.ts';
import { ErrorSlice, errorSlice } from '@/store/slice/error.slice.ts';
import { FetchStatusSlice, fetchStatusSlice } from '@/store/slice/fetch-status.slice.ts';
import { currentUserSlice, UserSlice } from '@/store/slice/user.slice.ts';
export type MainStoreType = AuthSlice & ErrorSlice & FetchStatusSlice & UserSlice;
// auto-generated selectors provided by Zustand
type WithSelectors<S> = S extends { getState: () => infer T } ? S & { use: { [K in keyof T]: () => T[K] } } : never;
const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(_store: S) => {
const store = _store as WithSelectors<typeof _store>;
store.use = {};
for (const k of Object.keys(store.getState())) {
(store.use as any)[k] = () => store(s => s[k as keyof typeof s]);
}
return store;
};
export const useMainStore = createSelectors(
createWithEqualityFn<MainStoreType>(
(...s) => ({
...authSlice(...s),
...currentUserSlice(...s),
...errorSlice(...s),
...fetchStatusSlice(...s)
}),
shallow
)
);
-
shallow
用於比較前後兩次的 state, 在 selector 與 state 之間的 reference 不同但 value 相同的情況下防止不必要的 re-render -
用
createWithEqualityFn()
取代create()
, 並在第二個參數帶入shallow
使用 -
或者你也可以在 component 中使用
useShallow
, 兩者皆可 (relative issue):App.tsxconst { currentUser, fetchProfile, fetchStatus, onFetchStatusChange, stVerifyQuery } = useMainStore(
useShallow((store: MainStoreType) => {
return {
currentUser: store.user.currentUser,
fetchProfile: store.user.fetchProfile,
fetchStatus: store.fetchStatus.fetchStatus,
onFetchStatusChange: store.fetchStatus.onFetchStatusChange,
stVerifyQuery: store.auth.stVerifyQuery
};
})
);
建立 API Request
Zustand 如果要發送一個 HTTP request, 可以透過 axios
來發送, 並且可以在 action 中直接使用 set()
來更新 state
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { casApiUrl } from '@/config.ts';
import { useMainStore } from '@/store';
import { ErrorType } from '@/store/slice/error.slice.ts';
import { getAccessTokenByCookie, removeAccessTokenCookie, saveAccessTokenCookie } from '@/utils/token.utility.ts';
const instance: AxiosInstance = axios.create({
baseURL: casApiUrl,
withCredentials: true
});
instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const accessToken = getAccessTokenByCookie();
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
error => Promise.reject(error)
);
instance.interceptors.response.use(
(response: AxiosResponse) => {
return response.data;
},
async error => {
if (error.response) {
const data = error.response.data.error as ErrorType;
switch (data.key) {
case 'ACCESS_TOKEN_IS_EXPIRED':
removeAccessTokenCookie();
try {
return instance({
url: `/auth/access-token/renew`,
method: 'GET'
}).then((response: AxiosResponse) => {
const newAccessToken = response.data;
saveAccessTokenCookie(newAccessToken);
error.config.headers.Authorization = `Bearer ${newAccessToken}`;
return instance.request(error.config);
});
} catch (renewError) {
return Promise.reject(renewError);
}
case 'REFRESH_TOKEN_IS_EXPIRED':
useMainStore.getState().error.onErrorDataChange('profile', data);
useMainStore.getState().user.onCurrentUserChange(undefined);
return Promise.reject(data);
default:
return Promise.reject(data);
}
} else {
return Promise.reject(error);
}
}
);
export { instance };
instance.interceptors.request
與instance.interceptors.response
用於攔截 request 與 response- 上面的 Api 邏輯同 Redux Toolkit 這篇 的
createApi
Slice
在 Component 中使用
在 Component 中可以直接從 store 中依據你的 hook 傳遞的 selector 來取得你想要的 state 或是 action 來使用
// ...
export const useAuth = () => {
const { currentUser, fetchProfile, fetchStatus, onFetchStatusChange, stVerifyQuery } = useMainStore((store: MainStoreType) => {
return {
currentUser: store.user.currentUser,
fetchProfile: store.user.fetchProfile,
fetchStatus: store.fetchStatus.fetchStatus,
onFetchStatusChange: store.fetchStatus.onFetchStatusChange,
stVerifyQuery: store.auth.stVerifyQuery
};
});
// ...
};
我們可以透過 Zustand 提供的 Auto Generating Selectors 方法來自動生成選擇器 (selectors), 可以讓你從狀態中直接選取某些部分, 而不必每次都手動撰寫這些 selectors (上面 store.ts
的 line 22-32), 在 Component 中就 可以透過 use
來取得對應 selector 中的狀態或 action
// ...
const HomePage: FC = () => {
const { currentUser } = useContext(GlobalContext);
// error 是我們在 error.slice.ts 中定義的 key, 裡面包含了 errorData 及 onErrorDataChange
const { errorData } = useMainStore.use.error();
return (
<div>
<h1>HomePage</h1>
{/*...*/}
{!isEmpty(errorData) && (
<div className="text-red-500">
<div>
Error:
{Object.keys(errorData).map(key => {
return <div key={key}>{errorData[key].message}</div>;
})}
</div>
</div>
)}
</div>
);
};
export default HomePage;
Context Api 的部分同 Redux Toolkit 這篇 一樣, 只差在怎麼取得 store 中的 props
其他的設定或 Demo 也都同樣, 可直接參考上篇